feat: contrato de superfície como matriz Node 24/26 - #13
Merged
Conversation
c6aef3e gave url.pathname a mutation lowering and flipped its census row to static, but left the operations table untouched — so the url profile declared 31 static inventory rows against 30 supported operations and the conformance tripwire fired on main. The setter now carries its own operation row with the corpus evidence that guards it (2854-url-pathname-setter) and a scope that says what the assignment does: re-run the WHATWG path parse, re-serialize href. The other nine writable components were probed by COMPILING one assignment each rather than by reading the lowering; all nine are still refused, so their refusal rows stand unchanged. The prose that claimed URL values have no setters at all — in the profile header, the ambient declaration, and the manifest's URL coverage note — is corrected to name the one exception. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
The version axis in profile-schema.ts existed but every profile's
`candidates` list was empty, so the "matrix" was a single pin wearing a
list's clothes. This fills it, and only with what a reflection actually
found.
The probe: reflect every interface each of the three profiles censuses —
statics, own prototype members, inherited members, public symbols, setters,
and the own properties of a constructed instance — under 24.15.0 and under
26.8.1, then diff.
The result, which is the answer the mission wanted written down:
- URL / URLSearchParams / the search-params iterator: IDENTICAL across
the two majors. Every row is shared.
- EventEmitter, including the three pre-private-field internals and the
instance census: IDENTICAL. Every row is shared.
- fetch: NOT identical. Node 26 (Undici 8.10.0) grows textStream() on
Request.prototype and Response.prototype — a decoded-text ReadableStream
the body mixin did not have under 24 (Undici 7.24.4). Two rows, and
only those two, carry a version qualifier.
So the schema addition is deliberately small: an inventory row may name the
target ids whose census contains it, and OMITTING that field means "every
target". A shared row cannot be narrowed by forgetting an id, and the
common case stays unannotated. The manifest stamps a shared row with the
primary's label as before and a qualified row with exactly the runtimes it
exists on, so "Node 26 only" is readable off the shipped manifest.
node-matrix.ts is the single place the runtimes are named — the profiles,
the conformance suites, and the matrix gate all read it rather than
repeating version literals. The fetch profile's bespoke {node, undici}
tuple is folded into the same CompatTargets shape the other two use, with
Undici as a pinned component of each target.
Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
All three conformance suites opened with the same assertion:
expect(process.versions.node).toBe(profile.targets.primary.node)
which turns every host that is not one exact build into a red — including
Node 26, a first-class target. That is a false red by construction, and it
is the papercut that bit four times in one day.
The suites now SELECT instead of demand: each asks the running runtime
which declared target it is, compares the reflection against that target's
rows, and fails only when the host is in no declared target at all — the
one condition that genuinely is a contract violation. The failure message
names the host and lists the declared runtimes, so the fix is obvious from
the output.
Selection is what makes the fetch profile's version-qualified rows work:
Request/Response.textStream is compared only under Node 26, and its absence
under Node 24 is not a mismatch. Verified in both directions — flipping the
qualifier to node24 makes the Node 26 census fail on exactly those two
members, so the tripwire is still a tripwire.
Two things deliberately stay matrix-wide rather than per-target: row SHAPE
validation (a malformed Node 26 row must fail on Node 24 too) and the
shipped-manifest comparison, since the manifest is one artifact carrying
the whole matrix whatever runtime generated it.
Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
`pnpm test:*-conformance` inherits whatever `node` the shell resolves, so "run the conformance suites" silently meant "run them under one runtime, whichever one that is" — useless as evidence for a two-runtime contract. scripts/node-matrix.mjs names the interpreter explicitly, one lane per declared target, and VERIFIES the binary it found by asking its --version: a moved mise symlink must not let the node26 lane run Node 24 and report PASS. Resolution order is override, running interpreter, mise install tree, `mise which`. The runtime list is read from node-matrix.ts, so a runtime cannot be in the gate without being in the contract. pnpm gate:node-matrix both runtimes, in sequence pnpm test:conformance:node24 one runtime by target id pnpm test:conformance:node26 pnpm test:conformance the three suites, host runtime Running it turned up a REAL Node 24 → 26 semantic divergence that had to be resolved before the gate could be green: Node 26 rewords AbortSignal.any's ERR_INVALID_ARG_TYPE from "signals can not be converted to sequence." to "signals cannot be converted to sequence.", and the generated fetch differential compared native stdout against process.execPath — so under Node 26 it red on Node's own typo fix, twice, saying nothing about either backend. That forced the distinction the single-pin world never had to make, now stated once in tests/harness/node-matrix.ts and enforced by both callers: the CENSUS follows the HOST — what members a runtime exposes is a question about the runtime you are on, and asking both majors is the entire point; the SEMANTIC ORACLE stays PINNED to the primary — a compiled binary reproduces one Node's observable behavior, message text included, and cannot reproduce two. SCRIPTC_NODE_ORACLE is how you go looking for divergences deliberately rather than tripping over them. The harness README documents the gate as the enforcement point, since CI does not run on the fork. Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
The compat profiles classify three slices member by member, which answers
"is this member supported?" but never "how much of Node is that?" — a
numerator with nothing under the line. This derives the denominator
mechanically from Node's own generated API database, for both matrix
runtimes.
Nothing here is classified, deliberately. An added class is not a support
claim in either direction; establishing what THERE IS is the deliverable,
and deciding what each class means for the compiler stays profile work.
The result, and the artifact a coverage dashboard can consume:
Node 24.15.0: 262 documented classes across 43 modules
Node 26.8.1: 279 documented classes across 44 modules
delta: +20 / -3
added async_context.RunScope, diagnostics_channel.{BoundedChannel,
BoundedChannelScope,RunStoresScope}, ffi.DynamicLibrary,
globals.QuotaExceededError, net.BoundSocket,
perf_hooks.ELDHistogram, stream_iter.{Share,SyncShare},
v8.SyncHeapProfileHandle, vfs.{MemoryProvider,RealFSProvider,
VirtualFileSystem,VirtualProvider}, webcrypto.{KangarooTwelveParams,
TurboShakeParams}, zlib.{ZipBuffer,ZipEntry,ZipFile}
removed assert.CallTracker, buffer.SlowBuffer, perf_hooks.IntervalHistogram
The URLs move, so the source is pinned by EXACT version — never /latest/,
never a major alias — and each artifact records the byte length and SHA-256
of the all.json it was derived from, which makes a silently republished
upstream detectable instead of assumed away.
The raw downloads are ~8 MB each and are cached rather than committed:
16 MB of generated JSON in-tree is a permanent cost every clone pays, and
the derived inventories are what the dashboard actually reads and what is
legible in a diff. `--vendor-raw` writes the upstream bytes in-tree for
anyone who wants that trade instead.
One normalization is load-bearing: Node's class `name` is prose, not an
identifier — bare, already module-qualified, or carrying an `extends`
clause. Normalizing to a bare identifier owned by its defining document is
what keeps a docs edit from showing up as an add AND a remove; before it,
the same run reported +24/-6 with three phantom pairs.
Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
oracle-environment.ts already asked the oracle for its own version rather
than trusting process.version, so the cache key was correct across majors —
but nothing tested it, and "correct by inspection" is what the matrix work
exists to stop accepting. Four regressions now pin the host/oracle split:
- the cache key separates the two matrix majors (and two patches of the
same major): a colliding key would serve one runtime's recorded stdout
to the other and call it parity;
- every declared target resolves to an interpreter that REPORTS that
exact version — the check that catches a moved mise symlink turning
the node26 lane into a second node24 lane;
- a wrong-version SCRIPTC_NODE_<TARGET> override raises instead of
silently falling through to another candidate;
- the differential oracle defaults to the matrix primary, not the host,
with SCRIPTC_NODE_ORACLE still winning.
Sampled the differential lanes against a Node 26 oracle across both native
backends — 1355-url-parse and 2854-url-pathname-setter (rust),
1654-ee-namespace (rust), 1644-ee-basics and 1794-searchparams-url-live
(c/llvm) — all green. The only semantic divergence found anywhere in this
work remains AbortSignal.any's reworded ERR_INVALID_ARG_TYPE.
Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Zera 12 dos 13 vermelhos conhecidos do differential e deixa o 13º com diagnóstico completo (não é bug, é uma porta de backend que falta).
O achado que organiza o resto: parte dos vermelhos não era regressão do scriptc, era o ORÁCULO mudando entre Node 24 e Node 26. Esta máquina roda Node 26 por padrão, então o corpus estava sendo comparado contra um oráculo diferente do que fixou aquelas fixtures. Como suportamos os dois majors, uma fixture não pode depender de comportamento que muda entre eles — essas foram normalizadas, cada uma preservando o resto da cobertura.
Tabela: fixture → causa-raiz → veredito
positionmesmo com janela de leitura VAZIA; v24 fazia curto-circuito antesread()semsizeparou de concatenar o buffer interno (nodejs/node#60441, semver-major, 26.0.0); o async-iterator herdabuiltinModules.lengthmuda entre majors (72 no v24, 66 no v26)'buffer'— e revelou que não havia lowering geral dewrite(string, encoding).finallydescartava a promise do callback — rejeição escapava como unhandledrl.nextLinesó existe no backend Rust; o emissor C recusa de propósitoOs bugs reais
ABI do islandRest (2568, 2590) — o mais grave. Uma assinatura island-rest SOletra seu parâmetro final (array do engine) na lista de params e é marcada
rest. O thunk de chamada boxed lia as duas coisas literalmente: preenchia todo param posicionalmente — entregando ao slot final o primeiro argumento EXCEDENTE onde a closure espera o pacote — e ainda anexava um array dyn extra que o callee não tem parâmetro para receber. Ou seja,const f = (...args) => args.length; f(1, 2)num programa--dynamicestourava comexpected number, got undefined, porqueargsera o número1. Uma arrow no topo do módulo é um global dyn, então a chamada passa por esse thunk e não pelo caminho direto. Todo idioma de rest-forwarding sob--dynamicquebrava assim.2857-island-rest-boxed-call.jsfixa a ABI de forma mínima nas três lanes..finallysobre promise dyn (2210). O callback tinha o resultado descartado — um atalho documentado no próprio código ("that refinement waits for a use"). Duas consequências: uma cleanup que REJEITA nunca chegava à cadeia (o binário morria com unhandled rejection onde o JS substitui a settlement), e não esperar uma cleanup que RESOLVE também settlava cedo demais, o que era a causa real da ordem trocada entrefinally kept 7efinally ran. A reação agora percorre um resultado-promise como o braço.thenjá fazia.optChain island → union (2716). O braço jsval do emissor LLVM só tinha resultado void e resultado do engine, e lançava
InternalCompilerErrorno resto. Um passo que aterrissa de volta no mundo ESTÁTICO (flatValue(...)?.trim()) respondestring | undefined. Ganhou as mesmas duas formas que o emissor C já tinha; um kind não modelado agora viraLlvmUnsupportedError(cerca de cobertura) em vez de alegar bug do emissor.Web globals da island (1120). Três divergências:
formDecodeandava por CODE UNIT do UTF-16 e entregava surrogates soltos ao TextEncoder (astral virava dois U+FFFD);entries/keys/values/forEachtiravam snapshot quando a iteração de pares do WebIDL é VIVA (índice posicional relendo a lista atual); ebtoa/atoblançavamErrorcom.namecarimbado em vez doDOMExceptionque o próprio prelúdio já define (.code5,instanceofverdadeiro). Nada sobvendor/foi tocado.write(string, encoding)em socket (2599). Só três formas eram lowered; qualquer outra encoding literal caía na cerca "write with 2 arguments" e o programa nem compilava. Uma encoding que o Node não conhece agora é oERR_UNKNOWN_ENCODINGsíncrono dele. Spellings conhecidas mas ainda não lowered (hex,base64) mantêm a cerca em vez de escrever bytes errados.Alias
import=de namespace não instanciado (1967). O lowering reproduzia o transform nativo do Node 24, que sempre emitiavar P = <entity>e por isso lançava ReferenceError no alias. O Node 26 removeu esse modo. Verificado contra o transform do próprio oráculo:transpileModuleelide o alias tanto para namespace type-only quanto ambient e só mantémvar R = Vquando V é instanciado — exatamente a condição typeOnly/ambient que o código já calculava, então o throw virou elisão.Mudança no harness (vale revisar com atenção)
nodeTransformTypesArgsagora usa SEMPRE o hook TypeScript, em todo major. Selecionar pelo major tornava o oráculo dependente da versão, e uma fixture não pode ser fixada byte-a-byte contra um oráculo que muda de ideia com o Node do host. O cache do oráculo subiu paraoracle-v5para que caches quentes de Node 24 não sirvam vereditos do transform nativo. Os 20 programas@transform-typesforam verificados estáveis nos dois majors sob o hook, e os outros 36 programas da coorte seguem passando.Issue: portar
rl.nextLinepara o runtime C2794 não é regressão — é cobertura de backend faltando, e o emissor C já declara isso no código ("The native async-iterator slice currently belongs to the Rust runtime").
for await (const line of rl)baixa para o libCallrl.nextLine(Promise<string | undefined>), implementado só embackend/rust/readline.ts. O emissor C lançaInternalCompilerErrorde propósito; a lane LLVM falha pelo mesmo erro porque cai no fallback C — então uma implementação em C resolve as duas lanes.O que falta, concretamente:
packages/runtime/src/scr_readline.c— o slot de callback pendente dequestiontem quase a forma certa, mas falta o modo "próxima linha OU fim": hojescr_rl_settle_closeDESCARTA um callback pendente (aquestiondo Node nunca responde), enquantonextLineprecisa resolverundefinedno fim do stdin, e resolver de novo em toda chamada seguinte para o laçofor awaitterminar. Uma linha já bufferizada tem de responder na hora, comoquestionfaz viascr_rl_drain.packages/compiler/src/backend/emission/emit-exprs.ts— o casorl.nextLinecriascr_promise_new()e passa um adaptador internado por union que montastring | undefinede cumpre a promise. O precedente exato já existe:scr_promise_race_add(result, p, &adapter)comE.raceAdapterFor.O risco não é o tamanho, é a corretude assíncrona: manter o event loop vivo enquanto um
nextLineestá pendente, resolverundefinedexatamente uma vez no EOF, ecrlfDelay: Infinity. Preferi deixar diagnosticado a entregar meia implementação de I/O assíncrono.Follow-up sugerido: adotar (ou não) a semântica de streams do Node 26
read()semsizedo scriptc concatena o buffer inteiro — a semântica pré-26, e a que a documentação do Node ainda descreve. A #60441 mudou isso no 26.0.0 sem atualizar os docs. O corpus agora evita a superfície nos dois lados, mas o runtime segue implementando uma só, e essa escolha merece uma decisão explícita. Mesma coisa, menor, para o'buffer'dowritede socket: a mensagem especial do v24 continua no lowering, sem cobertura de corpus nos dois lados.Gates
-tnas fixtures corrigidas + 2716-tnas tocadas🤖 Generated with Claude Code
https://claude.ai/code/session_01Bab3v6PNzMBUq7nJhLR8i72.
surface-manifest.test.tsjá vermelho no main, sem relação com este trabalho:fs.writeFileExclusiveModeSyncaparece na lista de "attestation-demoting spellings no declarable fence can deny". Confirmado comgit stashem árvore limpa.coverage.test.tsestoura o timeout nesta máquina, também no main. O testeevery corpus program is 100% staticanalisa 1254 programas e traz timeout próprio de 600s. Medido, não suposto:node-matrix-contractnode-matrix-contract(2ª rodada)origin/mainsem modificaçãoMesmo comportamento com e sem as mudanças — ambiental, não regressão. O teste é feito para rodar shardado na lane completa (
shardSuffix()), e o comentário dele já avisa que "the default per-test timeout is far too small for a whole-corpus analysis". Rodar unsharded numa máquina carregada raspa o cap. Nota:--testTimeoutna CLI não sobrepõe o timeout que o teste passa como argumento — a primeira tentativa de subir o limite por flag foi inócua.---|---|---|
| 1640-fd-read-decode | Node 26 valida
positionmesmo com janela de leitura VAZIA; v24 fazia curto-circuito antes | sensível a versão → fixture || 1746-stream-for-await |
read()semsizeparou de concatenar o buffer interno (nodejs/node#60441, semver-major, 26.0.0); o async-iterator herda | sensível a versão → fixture || 2813-readable-async-iterator-chunks | mesma #60441 | sensível a versão → fixture |
| 2631-create-require |
builtinModules.lengthmuda entre majors (72 no v24, 66 no v26) | sensível a versão → fixture || 2599-stream-arg-ladders | v26 removeu o caso especial da encoding
'buffer'— e revelou que não havia lowering geral dewrite(string, encoding)| sensível a versão + bug real → corrigido || 1967-namespace-alias-typeonly | o harness escolhia o transform pelo major (flag nativa no 24, hook tsc no 26) e os dois discordam | harness + compilador corrigidos |
| 2084-destructuring-primitive-sources | mensagem de erro do quickjs-ng ≠ V8, em erro lançado DENTRO da island | divergência documentada → fixa o TIPO do erro |
| 2568-rest-spread-forward-dynamic | ABI islandRest: o thunk boxed nunca montava o array de rest | corrigido (C + LLVM) |
| 2590-object-create-dynamic | mesmo bug de ABI islandRest | corrigido |
| 2716-island-optional-string-method | optChain jsval no emissor LLVM não tinha a forma de resultado UNION | corrigido |
| 2210-dyn-promise-crossing |
.finallydescartava a promise do callback — rejeição escapava como unhandled | corrigido || 1120-web-globals-encoders | três bugs nos nossos próprios web globals da island | corrigido |
| 2794-readline-async-iterator |
rl.nextLinesó existe no backend Rust; o emissor C recusa de propósito | documentado (issue abaixo) |Os bugs reais
ABI do islandRest (2568, 2590) — o mais grave. Uma assinatura island-rest SOletra seu parâmetro final (array do engine) na lista de params e é marcada
rest. O thunk de chamada boxed lia as duas coisas literalmente: preenchia todo param posicionalmente — entregando ao slot final o primeiro argumento EXCEDENTE onde a closure espera o pacote — e ainda anexava um array dyn extra que o callee não tem parâmetro para receber. Ou seja,const f = (...args) => args.length; f(1, 2)num programa--dynamicestourava comexpected number, got undefined, porqueargsera o número1. Uma arrow no topo do módulo é um global dyn, então a chamada passa por esse thunk e não pelo caminho direto. Todo idioma de rest-forwarding sob--dynamicquebrava assim.2857-island-rest-boxed-call.jsfixa a ABI de forma mínima nas três lanes..finallysobre promise dyn (2210). O callback tinha o resultado descartado — um atalho documentado no próprio código ("that refinement waits for a use"). Duas consequências: uma cleanup que REJEITA nunca chegava à cadeia (o binário morria com unhandled rejection onde o JS substitui a settlement), e não esperar uma cleanup que RESOLVE também settlava cedo demais, o que era a causa real da ordem trocada entrefinally kept 7efinally ran. A reação agora percorre um resultado-promise como o braço.thenjá fazia.optChain island → union (2716). O braço jsval do emissor LLVM só tinha resultado void e resultado do engine, e lançava
InternalCompilerErrorno resto. Um passo que aterrissa de volta no mundo ESTÁTICO (flatValue(...)?.trim()) respondestring | undefined. Ganhou as mesmas duas formas que o emissor C já tinha; um kind não modelado agora viraLlvmUnsupportedError(cerca de cobertura) em vez de alegar bug do emissor.Web globals da island (1120). Três divergências:
formDecodeandava por CODE UNIT do UTF-16 e entregava surrogates soltos ao TextEncoder (astral virava dois U+FFFD);entries/keys/values/forEachtiravam snapshot quando a iteração de pares do WebIDL é VIVA (índice posicional relendo a lista atual); ebtoa/atoblançavamErrorcom.namecarimbado em vez doDOMExceptionque o próprio prelúdio já define (.code5,instanceofverdadeiro). Nada sobvendor/foi tocado.write(string, encoding)em socket (2599). Só três formas eram lowered; qualquer outra encoding literal caía na cerca "write with 2 arguments" e o programa nem compilava. Uma encoding que o Node não conhece agora é oERR_UNKNOWN_ENCODINGsíncrono dele. Spellings conhecidas mas ainda não lowered (hex,base64) mantêm a cerca em vez de escrever bytes errados.Alias
import=de namespace não instanciado (1967). O lowering reproduzia o transform nativo do Node 24, que sempre emitiavar P = <entity>e por isso lançava ReferenceError no alias. O Node 26 removeu esse modo. Verificado contra o transform do próprio oráculo:transpileModuleelide o alias tanto para namespace type-only quanto ambient e só mantémvar R = Vquando V é instanciado — exatamente a condição typeOnly/ambient que o código já calculava, então o throw virou elisão.Mudança no harness (vale revisar com atenção)
nodeTransformTypesArgsagora usa SEMPRE o hook TypeScript, em todo major. Selecionar pelo major tornava o oráculo dependente da versão, e uma fixture não pode ser fixada byte-a-byte contra um oráculo que muda de ideia com o Node do host. O cache do oráculo subiu paraoracle-v5para que caches quentes de Node 24 não sirvam vereditos do transform nativo. Os 20 programas@transform-typesforam verificados estáveis nos dois majors sob o hook, e os outros 36 programas da coorte seguem passando.Issue: portar
rl.nextLinepara o runtime C2794 não é regressão — é cobertura de backend faltando, e o emissor C já declara isso no código ("The native async-iterator slice currently belongs to the Rust runtime").
for await (const line of rl)baixa para o libCallrl.nextLine(Promise<string | undefined>), implementado só embackend/rust/readline.ts. O emissor C lançaInternalCompilerErrorde propósito; a lane LLVM falha pelo mesmo erro porque cai no fallback C — então uma implementação em C resolve as duas lanes.O que falta, concretamente:
packages/runtime/src/scr_readline.c— o slot de callback pendente dequestiontem quase a forma certa, mas falta o modo "próxima linha OU fim": hojescr_rl_settle_closeDESCARTA um callback pendente (aquestiondo Node nunca responde), enquantonextLineprecisa resolverundefinedno fim do stdin, e resolver de novo em toda chamada seguinte para o laçofor awaitterminar. Uma linha já bufferizada tem de responder na hora, comoquestionfaz viascr_rl_drain.packages/compiler/src/backend/emission/emit-exprs.ts— o casorl.nextLinecriascr_promise_new()e passa um adaptador internado por union que montastring | undefinede cumpre a promise. O precedente exato já existe:scr_promise_race_add(result, p, &adapter)comE.raceAdapterFor.O risco não é o tamanho, é a corretude assíncrona: manter o event loop vivo enquanto um
nextLineestá pendente, resolverundefinedexatamente uma vez no EOF, ecrlfDelay: Infinity. Preferi deixar diagnosticado a entregar meia implementação de I/O assíncrono.Follow-up sugerido: adotar (ou não) a semântica de streams do Node 26
read()semsizedo scriptc concatena o buffer inteiro — a semântica pré-26, e a que a documentação do Node ainda descreve. A #60441 mudou isso no 26.0.0 sem atualizar os docs. O corpus agora evita a superfície nos dois lados, mas o runtime segue implementando uma só, e essa escolha merece uma decisão explícita. Mesma coisa, menor, para o'buffer'dowritede socket: a mensagem especial do v24 continua no lowering, sem cobertura de corpus nos dois lados.Gates
-tnas fixtures corrigidas + 2716-tnas tocadas🤖 Generated with Claude Code
https://claude.ai/code/session_01Bab3v6PNzMBUq7nJhLR8i7